﻿using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;

namespace dIRC
{

	/// <summary>
	/// Manages a connection to an IRC server, including automatic PONG
	/// </summary>
	public sealed class IRCSocket
	{

		#region Events

		/// <summary>
		/// Occurs when a message is received from the server.
		/// </summary>
		public event EventHandler<MessageReceivedEventArgs> MessageReceived = delegate { };

		#endregion

		#region Constants

		/// <summary>
		/// The default port that IRC servers listen on.
		/// </summary>
		const int DEFAULT_IRC_PORT = 6667;
		/// <summary>
		/// The size of packet the socket receives.
		/// This value should be large enough to receive a full message, according to IRC RFCs.
		/// </summary>
		const uint RECEIVED_PACKET_SIZE = 512;

		/// <summary>
		/// The 'PING' command
		/// </summary>
		const string M_PING = "PING";
		/// <summary>
		/// The 'PONG' command
		/// </summary>
		const string M_PONG = "PONG";

		#endregion

		#region Private Classes

		private struct ResponsePacket
		{
			public byte[] Data;
			public Socket Connection;
		}

		#endregion

		#region Variables

		// Lifeblood of the IRC connection
		private Socket __connection;
		// Holds any data overflow
		private string __overflow = "";

		/// <summary>
		/// The server that the IRC instance is connecting to. To use a hostname, use the function SetServer()
		/// </summary>
		public IPAddress Server { get; set; }
		/// <summary>
		/// The port to connect to on the server (defaults to DEFAULT_IRC_PORT)
		/// </summary>
		public int Port { get; set; }

		#endregion

		#region Properties

		/// <summary>
		/// Gets a value that indicates whether the IRCSocket is connected to a remote host, as of the last send or receive.
		/// </summary>
		public bool Connected { get { return __connection.Connected; } }
		/// <summary>
		/// Gets the socket that is used to communicate with the server.
		/// </summary>
		[Obsolete("Using the raw socket should not be necessary. If it is, please start a discussion on CodePlex.",false)]
		public Socket Socket { get { return __connection; } }

		#endregion

		#region General Methods

		/// <summary>
		/// Sets the server IP based off an address or hostname, using DNS resolution.
		/// </summary>
		/// <param name="server"></param>
		public IRCSocket SetServer(string server)
		{
			if (String.IsNullOrEmpty(server))
				throw new ArgumentException("'server' is null or empty.", "server");
			try
			{
				Server = System.Net.Dns.GetHostAddresses(server)[0];
				return this;
			}
			catch (SocketException ex)
			{
				Exception newEx = ProcessSocketException(ex);
				if (newEx == null) { throw; }
				else { throw newEx; }
			}
		}

		#endregion

		#region Constructors

		/// <summary>
		/// Creates an IRCSocket without any properties set.
		/// </summary>
		public IRCSocket() { __Initialize(); }

		/// <summary>
		/// Creates an IRCSocket that is ready to connect to the specified server, on the default port.
		/// </summary>
		/// <param name="server">The hostname (or IP, as a string) that the socket will connect to</param>
		public IRCSocket(string server)
		{
			__Initialize();
			SetServer(server);
		}

		private void __Initialize()
		{
			__connection = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
			Port = DEFAULT_IRC_PORT;
		}

		#endregion

		#region Connection Methods

		/// <summary>
		/// Connects using the credentials given.
		/// </summary>
		/// <returns></returns>
		public IRCSocket Connect()
		{
			if (Server == null) throw new MissingServerAddressException();
			if (Port < 0 || Port > 65535) throw new InvalidPortException();
			try
			{
				__connection.Connect(this.Server, (int)this.Port);
			}
			catch(SocketException ex)
			{
				Exception newEx = ProcessSocketException(ex);
				if (newEx == null) { throw; }
				else { throw newEx; }
			}

			AcceptMessage();

			return this;
		}

		/// <summary>
		/// Instantly closes the connection, unless a timeout is specified.
		/// </summary>
		/// <returns></returns>
		public IRCSocket Disconnect(int timeout = 0)
		{
			__connection.Close(timeout);
			return this;
		}

		#endregion

		#region IO

		#region Input

		private void AcceptMessage()
		{ 
			ResponsePacket pkct = new ResponsePacket() { Connection = this.__connection, Data = new byte[RECEIVED_PACKET_SIZE] };
			this.__connection.BeginReceive(pkct.Data,0,pkct.Data.Length,SocketFlags.None,new AsyncCallback(ProcessData),pkct);
		}

		private void ProcessData(IAsyncResult i)
		{
			ResponsePacket resp = (ResponsePacket)i.AsyncState;
			resp.Connection.EndReceive(i);
			
			// Combine the previous overflow with the new data
			string data = __overflow + System.Text.Encoding.ASCII.GetString(resp.Data).TrimEnd('\0');

			// Create a stack (FIFO) of messages received, split by \r\n, leaving blank strings where they occur.
			Queue<string> messages = new Queue<string>(data.Split(new string[] {"\r\n"},StringSplitOptions.None));

			// Process the new messages in order received
			while (messages.Count > 1) { Buffer(messages.Dequeue()); }
			// Throw the last message into the overflow. If the data received ended in \r\n, this will be blank.
			__overflow = messages.Dequeue();

			// Accept the new lot of data.
			AcceptMessage();
		}

		private void Buffer(string message)
		{
			if (string.IsNullOrEmpty(message)) return;
			string[] parts = message.Split(' ');
			if (parts[0] == M_PING)
			{
				if(parts.Length > 1)
				{ Send(string.Format("{0} {1}\r\n", M_PONG, parts[1])); }
				else
				{ Send(string.Format("{0}\r\n", M_PONG)); }
			}
			MessageReceived(this, new MessageReceivedEventArgs(message));
		}

		#endregion

		#region Output

		/// <summary>
		/// Sends the string to the server, automatically terminated by a CRLF.
		/// </summary>
		/// <param name="data">Data to pass to the server</param>
		public void Send(string data)
		{
			SendRaw(string.Format("{0}\r\n",System.Text.Encoding.ASCII.GetBytes(data)));
		}

		/// <summary>
		/// Sends the raw string to the server.
		/// </summary>
		/// <param name="data">Data to pass to the server</param>
		public void SendRaw(string data)
		{
			SendRaw(System.Text.Encoding.ASCII.GetBytes(data));
		}

		/// <summary>
		/// Sends the raw bytes to the server.
		/// </summary>
		/// <param name="data">Data to pass to the server</param>
		public void SendRaw(byte[] data)
		{
			__connection.Send(data);
		}

		#endregion

		#endregion

		#region Exception Handling

		private Exception ProcessSocketException(SocketException ex)
		{
			switch (ex.SocketErrorCode)
			{
				case SocketError.HostNotFound:
					return new ServerNotFoundException(ex);
				case SocketError.TimedOut:
					return new SocketTimeoutException(ex);
				default:
					return null;
			}
		}

		#endregion

	}
}
